Los datos entregados en este reto corresponden a transacciones realizadas por clientes persona del banco vĆa PSE. Estas transacciones, a diferencia de las transacciones realizadas vĆa POS, no cuentan con un código MCC atado a la transacción, que permite conocer la categorĆa de comercio a la que pertence el establecimiento de comercio donde se realiza la transacción. Adicionalmente, muchas de estas transferencias por PSE corresponden a transferencias de pagos de servicios pĆŗblicos, seguros, colegios, arrendamientos, y otros gastos que pueden ser denominados como gastos grandes. En el marco de un sistema de gestión de finanzas personales, poder categorizar adecuadamente estas transacciones que se realizan por PSE es de suma importancia para contar con una foto completa de la actividad de gastos de los clientes. Para este reto, los equipos participantes tendrĆ”n acceso a una muestra de transacciones PSE que corresponden a algo mĆ”s de 300 mil clientes (persona), seleccionados de manera aleatoria. La tabla de transacciones cuenta con 11.8 millones de registros (uno para cada transacción), realizados entre septiembre de 2016 y octubre de 2018.
NOTA Los datos han pasado por un proceso relativamente simple de curación, pero se han dejado algunos ruidos en la calidad de éstos con el fin de que los equipos también lleven a cabo un proceso de inspección y limpieza.
| Campo | Descripción | Tipo |
|---|---|---|
| id_trn_ach | identificador único de transacción | string |
| id_cliente | id. Ćŗnico de cliente (pagador) | bigint |
| fecha | fecha de transacción | decimal(8,0) |
| hora | hora de transacción (HHMMSS) | decimal(6,0) |
| valor_trx | valor ($) transacción | double |
| ref1 | texto libre referencia 1 | string |
| ref2 | texto libre referencia 2 | string |
| ref3 | texto libre referencia 3 | string |
| sector | sector eco. receptor | varchar(24) |
| subsector | subsector eco. receptor | varchar(62) |
| descripcion | descripción subsector receptor | varchar(24) |
| Campo | Descripción | Tipo |
|---|---|---|
| id_cliente | id. Ćŗnico de cliente (pagador) | bigint |
| seg_str | segmento estructural | string |
| ocupacion | ocupación | string |
| tipo_vivienda | tipo de vivienda | string |
| nivel_academico | nivel acadƩmico | string |
| estado_civil | estado civil | string |
| genero | genero | string |
| edad | edad | int |
| ingreso_rango | rango de ingreso estimado | string |
El seg_str corresponde a la segmentación estructural, que solo depende de los ingresos reportados por el cliente y su tamaño comercial (volumen de activos y pasivos con el banco). Posibles valores son PERSONAL, PERSONAL PLUS, EMPRENDEDOR, PREFERENCIAL, OTRO (incluye también clientes no segmentados debido a falta de información de éstos).
ocupacion
| Código | Descripción |
|---|---|
| E | SOCIO O EMPLEADO - SOCIO |
| I | DESEMPLEADO CON INGRESOS |
| O | OTRA |
| P | INDEPENDIENTE |
| S | DESEMPLEADO SIN INGRESOS |
| 1 | EMPLEADO |
| 2 | ESTUDIANTE |
| 3 | INDEPENDIENTE |
| 4 | HOGAR |
| 5 | JUBILADO |
| 6 | AGRICULTOR |
| 7 | GANADERO |
| 8 | COMERCIANTE |
| 9 | RENTISTA DE CAPITAL |
Si en los datos aparece algún otro código no listado en la tabla anterior, es posible asumir que se trata de un valor nulo, no disponible para el cliente en cuestión.
tipo_vivienda
| Código | Descripción |
|---|---|
| A | ALQUILADA |
| R | ALQUILADA |
| F | FAMILIAR |
| I | NO INFORMA |
| P | PROPIA |
| O | PROPIA |
Si en los datos aparece algún otro código no listado en la tabla anterior, es posible asumir que se trata de un valor nulo, no disponible para el cliente en cuestión.
nivel_academico
| Código | Descripción |
|---|---|
| H | BACHILLERATO |
| B | BACHILLERATO |
| U | UNIVERSITARIO |
| E | ESPECIALIZACION |
| N | NINGUNO |
| P | PRIMARIA |
| S | POSTGRADO |
| T | TECNICO |
| I | NO INFORMA |
estado_civil
| Código | Descripción |
|---|---|
| S | SOLTERO |
| M | CASADO |
| F | DESCONOCIDO |
| I | NO INFORMA |
| D | DIVORCIADO |
| W | VIUDO |
| O | OTRO |
genero
| Código | Descripción |
|---|---|
| F | FEMENINO |
| M | MASCULINO |
En el Banco ya se han llevado a cabo esfuerzos por categorizar transacciones provenientes del canal POS (con tarjetas dĆ©bito y crĆ©dito), lo cual ha incluĆdo, entre otras cosas, una depuración y limpieza de los códigos MCC. A continuación mostramos, a manera de referencia, la categorización propuesta por el equipo.
Recuerden que esta información aĆŗn contiene un elevado nivel de ruido. No solo no ha sido depurada de posibles datos atĆpicos (transacciones de valor muy elevado) fruto de errores o transacciones fallidas, sino que tambiĆ©n cuenta con el ruido asociado al campo de referencia, donde se involucra el factor humano, ya que son campos de texto libre que pueden contener cualquier tipo de información.
Por seguridad, hemos eliminado cualquier número presente en dichos campos de referencia (cédulas, nits, montos, contratos, etc.).
Para la solución a este reto se utilizarĆ” la metodologĆa CRISP-DM, mediante la cual se busca generar un resultado que haga un uso adecuado de los datos y permita resolver de forma efectiva el problema inicial planteado. De esta forma, se seguirĆ”n los siguientes pasos:
Se debe tener presente también que a partir del entendimiento del negocio se plantearÔn varias soluciones diferentes, que puedan aportar desde diferentes perspectivas tanto a los clientes del banco (pagadores) como al banco por sà mismo.
En esta fase se buscarĆ” entender el negocio en lo referente al funcionamiento del botón de Pagos seguros en lĆnea PSE.
PSE es un sistema centralizado y estandarizado que permite a las empresas ofrecer al Usuario la posibilidad de realizar pagos en lĆnea, accesando sus recursos desde la Entidad Financiera donde los tiene.
PSE es un servicio de ACH COLOMBIA S.A. quien es miembro de la Asociación Nacional de CĆ”maras de Compensación Automatizadas de Estados Unidos conocida como entidad que rige los procedimientos, normas y formatos de los ACH en ese paĆs, donde el sistema ACH existe hace mĆ”s de 25 aƱos.
PSE cuenta en colombia con mÔs de 6000 empresas suscritas para la realización de pagos mediante el portal.
Entidades financieras vinculadas al servicio PSE
Algunas ventajas para Grandes, medianas y pequeƱas empresas
Ventajas para usuarios
consultado en lĆnea en: https://bit.ly/2AuCbcY
El reto que se tiene es lograr clasificar las transacciones en una serie de categorĆas definidas por el banco, para lo cual el proceso que se seguirĆ” es:
Finalmente, se proponen una serie de mockups que permitan llevar los modelos propuestos a la prÔctica. Aunque esto es algo que posiblemente requiera trabajos mÔs profundos de co-creación, se hace esta aproximación para lograr que los modelos propuestos vayan mÔs allÔ de solo el procesamiento de datos, y llegue a una propuesta de generación de valor tanto para el banco como para el cliente.
En esta fase el objetivo principal es poder hacer una captura inicial de los datos a analizar para familiarizarse con ellos, identificar problemas de calidad de los mismos, detectar subconjuntos que pudieran ser interesantes para formular hipótesis, e incluso identificar las primeras claves del conocimiento que se puede extraer de los datos.
Este fase de la metodologĆa comprende una serie de etapas que se listan a continuación:
Descipción de los datos: este acercamiento inicial permite identificar los tipos de variables, su tamaño, entre otros.
Exploración de los datos: permite tener una idea del comportamiento de los registros y posibles relaciones entre los campos del dataset. Esta exploración incluye generalmente un anĆ”lisis descriptivo a partir de la información estadĆstica del conjunto.
Calidad de los datos: brinda un diagnóstico del conjunto de datos, en esta actividad se verifica la presencia de valores duplicados, nulos y datos atĆpicos que pueden afectar el desempeƱo de los modelos que se realicen.
# Carga las librerĆas para el procesamiento de la información
# https://medium.freecodecamp.org/how-to-transfer-large-files-to-google-colab-and-remote-jupyter-notebooks-26ca252892fa
!pip install PyDrive
import os
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
from google.colab import auth
from oauth2client.client import GoogleCredentials
# Autorización a Google SDK para acceder a Google Drive from Colab
auth.authenticate_user()
gauth = GoogleAuth()
gauth.credentials = GoogleCredentials.get_application_default()
drive = GoogleDrive(gauth)
# Hace la carga de los archivos:
# https://drive.google.com/open?id=1MruYy0JMav39Hx6HDjdcgCHaSFZ99lx- -->
download = drive.CreateFile({'id': '1MruYy0JMav39Hx6HDjdcgCHaSFZ99lx-'})
download.GetContentFile('excluded_words.csv')
# https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> trxpse
download = drive.CreateFile({'id': '1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt'})
download.GetContentFile('dt_trxpse_personas_2016_2018_muestra_adjt.csv')
# https://drive.google.com/open?id=18jlmOqyiJniFxKhaCpWDQYVQvhsYEBBZ --> info_pagadores (clientes)
download = drive.CreateFile({'id': '18jlmOqyiJniFxKhaCpWDQYVQvhsYEBBZ'})
download.GetContentFile('dt_info_pagadores_muestra.csv')
# https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> Archivos con las stopwords en espaƱol
download = drive.CreateFile({'id': '1Zcfgx-ak9vm6Ljzkg_A4Ms-koHxl-tgq'})
download.GetContentFile('stopwords_spanish.csv')
# https://drive.google.com/open?id=18jlmOqyiJniFxKhaCpWDQYVQvhsYEBBZ --> Relación entre los subsectores y las categorĆas
download = drive.CreateFile({'id': '1VckyawOWdRsNCRv_87wSRQabZlJyoQZD'})
download.GetContentFile('dt_subsector_categoria.csv')
# https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> Bolsa de palabras asociadas a cada categorĆa
download = drive.CreateFile({'id': '1K7FREJ4uU_UvvlANMh_3avvqnZNZxgwn'})
download.GetContentFile('df_bolsa_palabras.csv')
# https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> trx_pse ya clasificado, se utiliza para el Modelo Clasificador
download = drive.CreateFile({'id': '1AHcd_C-KD0FwFE2afjNfZ_fjOurI1NeN'})
download.GetContentFile('trx_pse_clasificado.csv')
# https://drive.google.com/open?id=1gDbQ8CUr2so8R3LSwZJDdWiImzyf1l3d --> Imagen utilizada en el proceso
download = drive.CreateFile({'id': '1gDbQ8CUr2so8R3LSwZJDdWiImzyf1l3d'})
download.GetContentFile('265000.PNG')
# https://drive.google.com/open?id=16mj5s7c1GLvYBav_RCxZ0C65PZrAwjA4 --> Imagen utilizada en el proceso
download = drive.CreateFile({'id': '16mj5s7c1GLvYBav_RCxZ0C65PZrAwjA4'})
download.GetContentFile('85000000.PNG')
# https://drive.google.com/open?id=1r41zAtXO4xW4E5KdKUNkJE-g0vlCFV6r --> Mockup
download = drive.CreateFile({'id': '1r41zAtXO4xW4E5KdKUNkJE-g0vlCFV6r'})
download.GetContentFile('mock_up.jpeg')
# Revisa los archivos que quedaron en el directorio de trabajo
!ls
# Import de las librerĆas base
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import EngFormatter
import seaborn as sns
%matplotlib inline
plt.style.use('seaborn-talk');
import csv
import itertools
import math
from random import sample
# Creación de función que luego se utilizarÔ para mostrar los primeros y últimos registros de un dataframe
def head_tail(n):
return(np.r_[0:n, -n:0])
# Carga las stopwrods en espaƱol
with open('stopwords_spanish.csv', 'r', encoding='latin-1') as f:
reader = csv.reader(f)
stopwords_spanish = list(reader)
stopwords_spanish = list(itertools.chain.from_iterable(stopwords_spanish))
print("Muestra de algunas stopwords:")
print(stopwords_spanish[0:10])
# Crea el dataframe asociado a la bolsa de palabras asociada a cada categorĆa
df_bolsa_palabras = pd.read_csv('./df_bolsa_palabras.csv', sep=',', encoding='latin-1')
# De este dataframe extrae la lista de clasificaciones
clasificaciones = df_bolsa_palabras.columns.tolist()
clasificaciones.append('')
clasificaciones
# Carga el archivo con la asignación de las clasificaciones objetivo para los subsectores definidos
df_subsector_categoria = pd.read_csv('./dt_subsector_categoria.csv', sep=',', encoding='latin-1')
df_subsector_categoria.iloc[head_tail(5)]
%%time
# Carga el archivo con la información de las personas, buscando optimizar el almacenamiento con la asignación de tipo de dato.
df_pagadores = pd.read_csv('./dt_info_pagadores_muestra.csv',
header=None,
sep=",",
names=['id_cliente', 'seg_str', 'ocupacion', 'tipo_vivienda', 'nivel_academico',
'estado_civil', 'genero', 'edad', 'ingreso_rango'],
dtype={'id_cliente': 'object', 'seg_str': 'category',
'ocupacion': 'object', 'tipo_vivienda': 'object',
'nivel_academico': 'object', 'estado_civil': 'object',
'genero': 'object', 'edad': 'object', 'ingreso_rango': 'category'})
df_pagadores.iloc[head_tail(5)]
%%time
# Carga el archivo con las transacciones.
# Hay una restricción con el último campo que tiene valores separados por comas que es el mismo separador de las columnas.
# Para mitigar esto, se divide el campo en 5 posibles posiciones
# Se utiliza ademÔs la opción quoting=3 para que se ignoren las comillas pues hay unos registros que las tienen y generan problemas
# AsĆ mismo, a pesar de que la columna "id_trn_ach" no se usa para los modelos de ML, se mantiene para el preprocesamiento de datos
df_trxpse = pd.read_csv('./dt_trxpse_personas_2016_2018_muestra_adjt.csv', #Para cargar como dataframe de pandas
#df_trxpse = dd.read_csv('./dt_trxpse_personas_2016_2018_muestra_adjt.csv',
header=None,
sep=",",
names=['id_trn_ach', 'id_cliente', 'fecha', 'hora', 'valor_trx',
'ref1', 'ref2', 'ref3',
'sector', 'subsector',
'descripcion_p1', 'descripcion_p2', 'descripcion_p3', 'descripcion_p4', 'descripcion_p5'],
dtype={'id_trn_ach': 'object', 'id_cliente': 'object',
'fecha': 'object', 'hora': 'object',
'ref1': 'category', 'ref2': 'category', 'ref3': 'category',
'sector': 'category', 'subsector': 'category',
'descripcion_p1': 'category', 'descripcion_p2': 'category',
'descripcion_p3': 'category', 'descripcion_p4': 'category',
'descripcion_p5': 'category'},
quoting=3) #.drop('id_trn_ach', axis=1)
# Vuelve a unificar el campo descripción, y elimina las columnas separadas que se habĆan creado
cols_descripcion = [col for col in df_trxpse.columns if 'descripcion' in col]
df_trxpse['descripcion'] = ''
for col in cols_descripcion:
df_trxpse['descripcion'] = df_trxpse['descripcion'] +' '+ df_trxpse[col].astype('object')
df_trxpse = df_trxpse.drop(cols_descripcion, axis=1)
print(df_trxpse.shape)
# FUnción para validar la cantidad de registros del archivo original
def file_len(fname):
with open(fname) as f:
for i, l in enumerate(f):
pass
return i + 1
registros = file_len('./dt_trxpse_personas_2016_2018_muestra_adjt.csv')
print(f'El archivo original contiene {registros} registros')
# Debido al tamaƱo del set de tados, revisa el uso de memoria asociado
df_trxpse.info(memory_usage='deep')
Se identifica que el dataframe estÔ ocupando alrededor de 4.1GB, con los siguientes pasos se busca tanto hacer una preparación inicial de los datos, como disminuir el consumo de almacenamiento para optimizar el uso de la memoria disponible (12GB).
# Hace una exploración del dataframe resultante
df_trxpse.iloc[head_tail(5)]
Se encuentra en esta primera exploración que para los campos de texto asociados a las referencias, y a la categorización del receptor hay mĆŗltiples campos con valores NaN o valores \N, por lo cual, antes de continuar con la exploración, y con el fin de tener un dataset mucho mĆ”s limpio, se procederĆ” cambiar estos valores por cadenas vacĆas. AsĆ mismo, se procederĆ” a realizar una limpieza general del texto mediante procesos como:
Para esto se crea una función, de tal forma que pueda ser luego utilizadas en todos los sets de palabras que se utilicen en el ejercicio.
# Crea la función para procesar el texto de los dataframes
def organiza_texto_df(df_proceso, columnas='', type_category=False):
# Si no se pasó un vector de columnas, crea un vector con todas las columnas del dataframe
if len(columnas) == 0:
columnas = df_proceso.columns.values
for col in columnas:
# Si se pasó la bandera de que se debe trabajar con dtype = category, hace el procesamiento teniendo en cuenta esto (category -> object -> category)
if type_category:
df_proceso[col] = df_proceso[col].astype('object').fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ').astype('category')
# De lo contrario, trabaja directamente con la columna teniendo en cuenta que es string
else:
df_proceso[col] = df_proceso[col].fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ')
print(f'Columna {col} procesada')
#Devuelve el dataframe prosesado
return(df_proceso)
%%time
# Genera un arreglo con los nombres de los campos de texto que describen la transacción
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion']
# Organiza el texto de las columnas con la caracterización de las transacciones
df_trxpse = organiza_texto_df(df_trxpse, cols_texto, True)
# Hace una exploración del dataframe resultante
df_trxpse.iloc[head_tail(5)]
# Se hace un describe de las 6 columnas de texto que categorizan la transacción
df_trxpse[cols_texto].describe()
Teniendo presente que "ref1", "ref2", "ref3", "sector", "subsector" y "descripcion" tienen una cantidad muy baja de valores únicos comparados con la cantidad de registros, se procede a mantener como tipo "category" para optimizar el uso de memoria. MÔs adelante se harÔn otros procesamientos con estos campos, pero siempre se buscarÔ volverlos a llevar a category para mantener un uso controlado de memoria y poder utilizarla para los prcesamientos pesados.
Se encuentra ademĆ”s que ref3 estĆ” vacĆa para todos los registros, pero por el momento se mantiene para que el modelo siga aplicando a pesar de que a futuro lleguen transacciones que tengan valores en este campo.
# Presenta nuevamente la cantidad de memoria utilizada
df_trxpse.info(memory_usage='deep')
Una vez realizado un ajuste inicial a los valores de los campos de texto, se procede a hacer una conversión de las columnas de fecha y hora para pasarlos de tipo "object" a tipo datetime.
# Primero se exploran los valores nulos y atĆpicos en este campo
print(f"Se encuentran {df_trxpse[df_trxpse['fecha'].isna()].shape[0]} registros con valor de fecha nulo")
# Luego se valida la información disponible para los registros con fecha nula, comenzando por los valores de transacción asociados
print(df_trxpse.loc[df_trxpse['fecha'].isna(), 'valor_trx'].value_counts(dropna=False))
Se encuentra que las filas que tienen valor de fecha nulo, tambiƩn tienen inconvenientes en el campo valor_trx pues este es NaN o tiene el valor de \N que no corresponde al tipo de dato que debe tener este campo. Sin embargo, se procede tambiƩn a revisar los valores de las columnas de texto para estos mismos registros.
df_trxpse[df_trxpse['fecha'].isna()].groupby(cols_texto).size().reset_index(name='counter')
Se encuentra que para todos estos registros los campos de texto que describen la transacción tambiĆ©n estĆ”n vacĆos, por lo cual se confirma que se puede proceder a eliminarlos.
# Se eliminan los registros de hora \\N y NaN.
df_trxpse = df_trxpse[df_trxpse['fecha'].notna()]
# Se eliminan los registros de fecha con NaN
df_trxpse.shape
Teniendo ya depurado el campo 'fecha', se procede a ajustar el campo 'hora' pues se encuentra que no todos los valores son de 6 dĆgitos como deberĆa ser pues su formato se indica como HHMMSS. Esto suele pasar cuando este tipo de valores pasan a ser asignados a un campo numĆ©rico y los que comienzan por 0 pierden caracteres, por ejemplo, las 000102 que corresponde a las 00 de la maƱana, con 1 minuto y 2 segundos se convierte en 102. Para mitigar esto se procede a agregar ceros a la izquiera para complementar la información y asegurar que todos los valores en este campo queden de 6 dĆgitos.
Posteriormente, se procede a contactenar la fecha y hora en un solo string, y este se convierte en fecha.
Finalmente, para tener mayor flexibilidad con la información al momento de hacer los diferentes procesamientos, se crean cuatro nuevas columnas:
# Ajusta el campo hora para que quede con 6 dĆgitoos
df_trxpse['hora'] = df_trxpse['hora'].str.zfill(6)
# Genera el campo 'Datetime' con la fecha y la hora en el formato correcto
df_trxpse['DateTime'] = df_trxpse['fecha'].astype('int64').astype(str)+df_trxpse['hora']
df_trxpse['DateTime'] = pd.to_datetime(df_trxpse['DateTime'],format='%Y%m%d%H%M%S')
# Agrega las nuevas columnas calculadas, indicando el tipo de dato asociado
df_trxpse['Year'] = df_trxpse['DateTime'].dt.year.astype('int16') # year
df_trxpse['Month'] = df_trxpse['DateTime'].dt.month.astype('int8') # month
df_trxpse['DayOfMonth'] = df_trxpse['DateTime'].dt.day.astype('int8') # day of week
df_trxpse['DayOfWeek'] = df_trxpse['DateTime'].dt.weekday_name.astype('category') # day of week
df_trxpse['Hour'] = df_trxpse['DateTime'].dt.hour.astype('int8') # hour
df_trxpse.drop(['fecha','hora'],axis=1,inplace=True)
df_trxpse.iloc[head_tail(5)]
Con lo anterior ya todas las columnas tienen el tipo adecuado de dato, salvo la asociada a 'valor_trx' que deberĆa ser numĆ©rica, por lo cual se procede a explorarla, ajustarla y convertirla. Cabe resaltar que esta se habĆa cargado en principio como "object" debido a que tenĆan valores NaN y "/N" que fueron depurados con la eliminación de registros que se hizo a partir de los que tenĆan campo fecha = NaN
# Hace un describe de los valores actuales de la columna 'valor_trx'
df_trxpse['valor_trx'].describe()
# Convierte la columna valor_trx a numƩrico
df_trxpse['valor_trx'] = pd.to_numeric(df_trxpse['valor_trx'])
# Hace un describe de los nuevos valores de la columna 'valor_trx'
df_trxpse['valor_trx'].describe().apply(lambda x: format(x, 'f'))
print(f"Hay {df_trxpse[df_trxpse['valor_trx']<10].shape[0]} registros con un valor menor a $10")
print(f"Hay {df_trxpse[df_trxpse['valor_trx']>100000000].shape[0]} registros con un valor mayor a $100 millones")
Se encuentra que ya la columna es de tipo numérica, aunque hay un outlier pues el valor mÔximo de una transacción fue de 1.788 millones, lo cual es ilógico para una transacción de PSE. En la parte de limpieza se procederÔ a definir el tratamiento a dar a este valor.
Adicionalmente, se procede a eliminar la columna "id_trn_ach" que no es Ćŗtil para el ejercicio, y se crea una nueva columna de tipo categórico con una cadena vacĆa que indica la categorĆa asociada a la transacción. Ćsta se irĆ” pobando en la medida en que logre ser relacionada a travĆ©s de los mĆ©todos que se desarrollan en las siguientes secciones.
# Genera la nueva columna con el listado de clasificaciones
df_trxpse['categoria'] = pd.Categorical(["" for x in range(len(df_trxpse.index.values))],
categories=clasificaciones,
ordered=False)
Finalmente, se procede a hacer una exploración final del tipo de datos definitivo para cada columna, y la cantidad de memoria utilizada.
# Presenta nuevamente la cantidad de memoria utilizada
df_trxpse.info(memory_usage='deep')
import gc
gc.collect()
Se encuentra que con los diferentes procesamientos y optimizaciones de memoria, se ha logrado llevar el tamaƱo en memoria del DataFrame de 4.1GB a solo 2.0GB, logrando una optimización de casi el 52% de su tamaƱoa pesar de haber agregado varias nuevas categorĆas.
FInalmente, se hace a continuación un procesamiento de la información asociada a las stopwords, que también involucra texto, para que se aplique el mismos proceso de limpieza que se sguió con el detaframe de las transacciones.
# Hace el procesamiento del arreglo con las stopwords
df_stopwords = pd.DataFrame(stopwords_spanish, columns=['stop_words'])
df_stopwords = organiza_texto_df(df_stopwords)
stopwords_spanish = df_stopwords['stop_words'].tolist()
print("Muestra de algunas stopwords:")
print(stopwords_spanish[0:10])
Para esta clasificación se parte del nuevo dataframe de las transacciones df_trxpse y sobre este se hacen varios procesos:
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo
df_texto = pd.DataFrame(pd.Series(df_trxpse[cols_texto].values.tolist()).str.join(' '), columns=['texto'])
df_texto['longitud'] = df_texto['texto'].str.len()
df_texto['num_palabras'] = df_texto['texto'].str.split().str.len()
# Hace una exploración de los primeros registros
df_texto.iloc[head_tail(5)]
df_texto.shape
# Grafica la longitud de los campos descriptores de la transacción contactenados
fig, ax = plt.subplots(figsize=(8,3))
df_texto['longitud'].plot.box(vert=False);
plt.title("Longitud de los campos descriptores del texto");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Caracteres', ylabel='');
# Grafica la cantidad de palabras de los campos descriptores de la transacción contactenados
fig, ax = plt.subplots(figsize=(8,3))
df_texto['num_palabras'].plot.box(vert=False);
plt.title("Cantidad de palabras de los campos descriptores del texto");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='NĆŗmero de Palabras', ylabel='');
Se encuentra hasta este punto que el nuevo dataframe creado tiene los mismos registros del dataframe de transacciones original, pero condensa los 6 campos descriptores de la transacción en uno solo.
Se encuentra tambiĆ©n que este nuevo campo tiene una longitud de 7 a mĆ”s de 7 caracteres, los cuales se distribuyen en una cantidad de 0 a 15 palabras. Cabe resaltar que aquellos campos sin palabras tienen 7 caracteres asociados a los espacios que se utilizaron para separar los campos que habĆa en el DataFrame Original.
COmo parte del procesamiento serĆ” necesario, mĆ”s adelante, dejar estos registros con la clasificación de "Otros", pues el no tener texto clasificador hace muy dificil relacionarlo con una de las categorĆas objetivo.
En este caso se utilizarĆ” Scikit Learn para hacer un procesamiento del texto, entender su detalle, definir cómo se prepararĆ” y luego aplicar mĆ©todos no supervisados como apoyo para asignar las categorĆas objetivo.
# Para hacer el procesamiento de texto
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
# Para el proceso de clusters
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples, silhouette_score
# Para presentar el wordcloud de las palabras
!pip install wordcloud
from wordcloud import WordCloud, ImageColorGenerator
Para la exploración inicial del texto se utilizarÔ una matriz TF (Term Frecuency) que permita identificar cuÔles son los términos mÔs y menos frecuentes, y a partir de ello hacer un proceso de preparación de los datos antes de proceder con la matriz TF-IDF (Term Frecuency-Inverse Document Frecuency) que serÔ a partir de la cual se creen los clusters.
# Genera la matriz TF para la totalidad de palabras en el dataset de transacciones, excluyendo solo stopwords
vectorizer = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=stopwords_spanish)
X_tf = vectorizer.fit_transform(df_texto['texto'])
# Obtiene el arreglo con las palabras y la frecuencia asociada a cada una
words = np.array(vectorizer.get_feature_names())
counts = X_tf.sum(axis=0).A1
# Crea un dataframe con la totalidad de palabras, y la frecuencia de cada una
all_words = pd.DataFrame({'frecuencia': counts}, index=words).sort_values(by='frecuencia', ascending=False)
umbral_low_frec = df_trxpse.shape[0]*0.001
umbral_unique_words = 100
umbral_min_chars =2
low_frec_words = all_words[all_words['frecuencia'] <= umbral_low_frec].index.values
unique_words = all_words[all_words['frecuencia'] <= umbral_unique_words].index.values
two_char_words = all_words[np.vectorize(len)(all_words.index.values) <= umbral_min_chars].index.values
print(f"El total de palabras identificadas en los textos eliminando las stopwords son {all_words.shape[0]}")
print(f"De estas, solo aparecen hasta {umbral_unique_words} veces un total de {all_words[all_words['frecuencia'] <= umbral_unique_words].shape[0]} palabras (Ćŗnicas)")
print(f"Y aparecen mƔximo {umbral_low_frec} veces ({umbral_low_frec*100/df_trxpse.shape[0]}%) un total de {all_words[all_words['frecuencia']<=umbral_low_frec].shape[0]} palabras (poco frecuentes)")
print(f"AsĆ mismo, hay un total de {len(two_char_words)} que tienen hasta {umbral_min_chars} caracteres")
all_words.iloc[head_tail(5)]
Hasta este punto se tiene el arreglo con la frecuencia de cada una de las palabras en todo el set de datos, y otros subsets asociados a:
Estos sets de palabras se utilizarƔn en el procesamiento del texto
A continuación se procede a hacer una exploración visual de los términos mÔs frecuentes.
# Genera la grƔfica con las 25 palabras mƔs frecuentes en las descripciones de todas las transacciones
all_words.head(25).plot.barh(title='Top 25 Palabras mƔs Frecuentes');
plt.gca().invert_yaxis();
# Genera un wordcloud con las palabras mƔs frecuentes
wordcloud = WordCloud(width=800,
height=400,
min_font_size=8,
max_font_size=60,
relative_scaling=0.3,
background_color="white").generate_from_frequencies(all_words['frecuencia'].head(200).to_dict())
# Display the generated image:
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()
Se encuentra en el wordcloud que hay múltiples palabras que no agregan valor para el ejercicio, como por ejemplo: CC, pago, pagos, no, referencia, pse, web, cr, paymentid, entre otras. Para facilitar este ejercicio se procede a hacer una revisión de las palabras mÔs frecuentes y asà generar manualmente un listado de palabras a excluir del ejercicion.
# NĆŗmero de columnas y filas en la tabla a presentar
n_cols = 10
n_rows = 30
# presentación de una matriz con las palabras mÔs frecuentes, para identificar términos a eliminar por no aportarle al ejercicio
pd.DataFrame(all_words.head(n_rows*n_cols).index.values.reshape(n_rows, n_cols))
A partir de esta exploración se define que se deben eliminar también las siguientes palabras que se cargan en el siguiente vector y que no se consideran relevantes para el ejercicio:
no_relevant_words = ['cc', 'cƩdula', 'cedula', 'pago', 'pagos', 'no', 'ni', 'null', 'id', 'pse', 'nit', 'online', 'ce', 'sa', 'sas', 'tpni',
'payu', 'paymentid', 'psepayment', 'payment', 'facturapayu', 'factura', 'contrato', 'contratos', 'referencia',
'mes', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre',
'otro', 'otros', 'idc', 'tipificado', 'ciudadania',
'hotmailcom', 'gmailcom']
Para esta etapa del procesamiento se harĆ” una identificación de aquellos registros que estĆ”n vacĆos o solo tienen palabras que se han identificado como no requeridas (stopwords_spanish, no_relevant_cords, unique_words, two_char_words), de tal forma que a todos ellos se les asigne la categorĆa objetivo "Otros", pues no se cuenta con información suficiente para hacer la clasificación.
# Genera el set de palabras a excluir
excluded_words = stopwords_spanish + no_relevant_words + unique_words.tolist() + two_char_words.tolist()
# Obtiene los registros que tienen no tienen palabras o solo tienen las excluidas => Sobre X_tf extrae todas las filas, pero solo columnas solo diferentes a las "excluded_words"
filas_clasificacion = X_tf[:, np.isin(words, excluded_words, invert=True)].sum(axis=1).A1 == 0
# Para las filas identificadas, asigna la categorĆa "Otros" pues por falta de información no es posible clasificar el registro
df_trxpse.loc[filas_clasificacion, 'categoria'] = 'Otros'
df_trxpse['categoria'].value_counts()
fig, ax = plt.subplots()
sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
Se encuentra hasta este punto que hay cerca de 2.5 millones de transacciones que no tienen información suficiente para ser clasificadas, pues sus 6 textos de referencia tienen una de las siguientes restricciones:
Teniendo presente que muchas de las transacciones tienen ya asignado un subsector, pues el receptor del pago es cliente del banco, se hizo una clasificación manual de a quĆ© categorĆa deberĆa pertenecer ese subsector, y por ello en esta etapa del procesamiento se procederĆ” a asignar esa categorĆa a las transacciones asociadas.
# Hace una preparación de los datos del dataframe df_subsector_categoria, bajo los mismos criterios que el resto de textos
df_subsector_categoria = organiza_texto_df(df_subsector_categoria, ['subsector'])
# Hace un loop para asignar a los respectivos registros la categorĆa asociada
for ind, fila in df_subsector_categoria.iterrows():
df_trxpse.loc[df_trxpse['subsector'] == fila['subsector'], ['categoria']] = fila['categoria']
fig, ax = plt.subplots()
sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
Con la intención de tener una fuente para hacer la clasificación de las transacciones, se realizó Web Scraping a la pĆ”gina de PSE, en donde se encuentra el listado de empresas que utilizando la plataforma, relacionando una categorĆa para cada una, por lo cual se obtuvo esta información y luego se mapeó con las categorĆas utilizadas como parte del anĆ”lisis.
# Hace import de las librerĆas para hacer Web Scrapping y limpiar el texto
import requests
from bs4 import BeautifulSoup
import re
import pandas as pd
from collections import OrderedDict
page_pse = requests.get("https://portal.psepagos.com.co/web/catalogo-pse?utm_campaign=catalogopse&utm_source=url_pse&utm_medium=pse")
soup_pse = BeautifulSoup(page_pse.content, 'html.parser')
html_pse = list(soup_pse.children)
Analizando el HTML de la pÔgina web , se encuentra que existen un campo llamado span en el que en algunas ocasiones es el mismo nombre de la empresa, sin embargo, se encuentra que para otras empresas es un campo con una serie de palabras en relación al tipo de empresa.
Se encuentran ademas que existen unas categorias que pueden asociarse directamente las categorias MCC (las objetivo en este ejercicio).
# Carga la información de las 3 etiquetas de interés
empresas = html_pse[2].find_all(class_="pse-banks__trade__title")
span = html_pse[2].find_all(style="display: none;")
# Código de categoria
categoria = html_pse[2].find_all(class_="pse-banks__trade-pagar-btn")
# Genera un dataframe con la información de interés de PSE
df_pse = pd.DataFrame({'Empresa': empresas,'Span': span,'Categoria_numero': categoria})
# Categorias de PSE por el HTML , mƔs unas adicionadas manualmente
pse_categorias = {'1425': 'Turismo','1413': 'Vivienda y Construcción','1418': 'Vivienda y Construcción','1427': 'Comercio','1428': 'Educación',
'1429':'Entretenimiento','1444': 'Financiero','1409': 'Educación','1481':'Fondo de Empleados', '1412':'Vehiculos', '1415':'Fundaciones',
'1433':'Gobierno','1434':'Salud','1435':'Otros Servicios', '1467':'servicios publicos', '1430':'Caja de Compensación', '1431':'Fundaciones',
'1439':'Transporte','1440':'Vivienda','1448':'Portal de Pagos Electrónicos - Banco de BogotÔ', '1454':'Fondo de Empleados',
'1449':'Multipagos PSE - Bancolombia','1450':'Centro de Pago PSE - Banco de Occidente','1464':'Gobierno','1478':'Gobierno',
'1465':'TecnologĆa y Comunicaciones','1466':'Financiero','1467':'Servicios PĆŗblicos y TV Por Cable'}
pse_categorias
# Funciones para "limpiar" el texto obtenido mediante Web Scrapping
def empresa_extract(empresa):
pattern ='^<div class="pse-banks__trade__title">(.*)</div>'
clean_empresa=re.findall(pattern, str(empresa))
clean_empresa=re.sub("[^a-zA-Z-0-9]", " ", str(clean_empresa))
return clean_empresa
def span_extract(span):
pattern ='^<div style="display: none;">\n<span>(.*)</span>\n</div>'
clean_span=re.findall(pattern, str(span))
clean_span=re.sub("[^a-zA-Z-0-9]", " ", str(clean_span))
return clean_span
def categoria_extract(cat):
pattern ='^<div class="pse-banks__trade-pagar-btn" id="btnpse_([0-9]*).*'
clean_cat=re.findall(pattern, str(cat))
return clean_cat[0]
def categoria(cat):
if cat in pse_categorias.keys():
return pse_categorias[cat]
else:
return 'Otros Servicios'
# Procede a limpiar los textos
df_pse['Empresa']=df_pse['Empresa'].apply(empresa_extract)
df_pse['Span']=df_pse['Span'].apply(span_extract)
df_pse['Categoria_numero']=df_pse['Categoria_numero'].apply(categoria_extract)
df_pse['Categoria']=df_pse['Categoria_numero'].apply(categoria)
# Hace una exploración de DataFrame Resultante
df_pse.iloc[head_tail(5)]
# Valida la cantidad de registros por cada categorĆa
df_pse['Categoria'].value_counts()
# Crea un dataframe que por cada categorĆa contiene tanto los nombres de las empresas, como las palabras de la sección Span
df_pse = df_pse[['Categoria', 'Empresa']].append(df_pse[['Categoria', 'Span']]).reset_index(drop=True)
df_pse.iloc[head_tail(5)]
# Cambiar las categorias por las MCC para lo que tiene una relación exacta
MCC_categorias = {'Vivienda y Construcción':'Hogar',
'Vivienda':'Hogar',
'Servicios PĆŗblicos y TV Por Cable':'Hogar',
'Salud':'Cuidado personal' ,
'Entretenimiento':'Entretenimiento' ,
'Educación': 'Educación' ,
'Vehiculos':'Transporte',
'Transporte':'Transporte',
'Viajes':'Turismo',
'TecnologĆa y Comunicaciones':'TecnologĆa y comunicaciones',
'Otros Servicios':'',
'Gobierno':'Gobierno e impuestos',
'Fundaciones':'',
'Comercio':''}
MCC_categorias
# Crea la función para relacionar ambas categorĆas
def categoria_MCC(cat):
if cat in MCC_categorias.keys():
return MCC_categorias[cat]
else:
return 'Otros'
# Procede a relacionar las categorĆas asociadas.
df_pse['Categoria_MCC']=df_pse['Categoria'].apply(categoria_MCC)
df_pse.drop(['Categoria'],axis=1,inplace=True)
df_pse.iloc[head_tail(5)]
# Ya teniendo la bolsa de palabras a partir de la información en PSE, aplica la función definida para ajustar los textos
df_pse = organiza_texto_df(df_pse, ['Empresa'])
df_pse.drop_duplicates(inplace=True)
# Genera un arreglo de "excluded_words", sin tener en cuenta las de baja frecuencia para lograr un anƔlisis mƔs profundo
excluded_words = stopwords_spanish + no_relevant_words
# Hace un split del texto en la columna Texto, y elimina aquellas de longitud menor o igual a 2 caracteres
df_pse['palabras'] = df_pse['Empresa'].apply(lambda x: [p for p in x.split() if ((len(p)>2) and (p not in (excluded_words)))])
df_pse['num_palabras'] = df_pse['palabras'].apply(lambda x: len(x))
# Organiza la bolsa de palabras para utilizar primero las que mƔs tƩrminos tienen y finalizar con las de menos
df_pse = df_pse.sort_values(by='num_palabras', ascending=False).reset_index(drop=True)
df_pse = df_pse.rename(index=str, columns={"Categoria_MCC": "categoria"})
df_pse = df_pse.loc[df_pse['num_palabras'] != 0, ['categoria', 'palabras', 'num_palabras']].reset_index(drop=True)
df_pse.iloc[head_tail(5)]
Hasta este punto se tiene un dataframe que agrupa listados de palabras asociados a categorĆas.
Para poder aplicar estas clasificaciones de forma óptima sobre la totalidad de transacciones, se procederĆ” a construir una matriz TF (Term Frecuency) que es frecuentemente utilizada en procesos de minerĆa de texto. Posteriormente, se llevarĆ” esta matriz a valores estrictamente booleanos, para luego hacer una revisión el cruce con los anteriores listados de tĆ©rminos por categorĆa para poder clasificar las transacciones.
%%time
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo, para la totalidad de palabras en el dataset de transacciones
df_texto = pd.DataFrame(pd.Series(df_trxpse.loc[:,cols_texto].values.tolist()).str.join(' '), columns=['texto'])
df_texto['categoria'] = df_trxpse['categoria']
# Genera un arreglo de "excluded_words", sin tener en cuenta las de baja frecuencia para lograr un anƔlisis mƔs profundo
excluded_words = stopwords_spanish + no_relevant_words + unique_words.tolist() + two_char_words.tolist()
# Genera la matriz TF para la totalidad de palabras en el dataset de transacciones
vectorizer = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
X_tf = vectorizer.fit_transform(df_texto['texto'])
# Lleva los valores de la matriz TF a una estrictamente booleana
X_tf_bool = X_tf > 0
# A partir del TF booleano, crea un SparseDataFrame con el fin de optimizar el uso del espacio
sdf = pd.SparseDataFrame(X_tf_bool, columns=vectorizer.get_feature_names(), default_fill_value=False)
sdf.shape
%%time
# Genera un arreglo con el total de palabras disponibles en la matriz TF
palabras_tf = sdf.columns.tolist()
# Variable para almacenar la cantidad de transacciones que se logran clasificar a travƩs de este mƩtodo.
trx_clasificadas = 0
# Hace un loop por los tĆ©rminos de la bolsa de palabras, y asocia la categorĆa respectiva a los registros en el dataframe df_trxpse
for ind, fila in df_pse.iterrows():
if all(palabra in palabras_tf for palabra in fila['palabras']):
# Si la cantidad de palabras es mayor a 3, permite que una de ellas no coincida, de lo contrario busca total coincidencia.
if fila['num_palabras']>=3:
umbral_palabras = fila['num_palabras']-1
else:
umbral_palabras = fila['num_palabras']
filtro_trx = sdf[fila['palabras']].sum(axis=1) == fila['num_palabras']
df_trxpse.loc[(filtro_trx.tolist()) & (df_trxpse['categoria'] == ''), 'categoria'] = fila['categoria']
trx_palabras = sum(filtro_trx)
trx_clasificadas += trx_palabras
if trx_palabras > 0:
print(f'Si: {fila["palabras"]} - {trx_palabras}')
else:
pass #print(f'No estƔn todos los tƩrminos: {fila["palabras"]}')
print(f'Total de transacciones clasificadas a travƩs de este mƩtodo {trx_clasificadas}')
# Presenta el grƔfico con los resultados obtenidos hasta el momento
fig, ax = plt.subplots()
sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
Se encuentra que este método permitió identificar mÔs de 1.5M de transacciones, lo cual es un resultado interesante teniendo presente que se partió de la categorización que ya tiene el mismo PSE en su pÔgina Web. La única limitante de este método es que se depende de que esta categorización esté bien hecha, y que la información en la pÔgina se actualiza permanentemente para incluir las nuevas empresas que firmen convenios con PSE.
Se debe tener presente también que a pesar de este avance en la categorización, aun quedan casi 6M de transacciones por clasificar, por lo cual se continuarÔ el proceso mediante un nuevo acercamiento diferente detallado en la siguiente sección.
Para este proceso se partirĆ” de la misma matriz TF, convertida a booleana, que se utilizó en la metodologĆa de clasificación anterior.
La diferencia es que acĆ” se realizarĆ” la categorización a partir de una bolsa de palabras creada manualmente por los miembros del equipo, donde se relacionaron palabras "comunes" que consideramos podĆan estar asociadas a cada una de las categorĆas.
# Validación del contenido de la matriz sdf (booleana obtenida a partir de la TF)
sdf.iloc[head_tail(5)]
# Pasa el DataFrame de la Bolsa de Palabras por la función para organizar el texto respectivo
df_bolsa_palabras = organiza_texto_df(df_bolsa_palabras)
df_bolsa_palabras.head()
# Cambia la forma del dataframe con la bolsa de palabras para facilitar el procesamiento
df_bolsa_palabras = df_bolsa_palabras.unstack().reset_index()
df_bolsa_palabras = df_bolsa_palabras.rename(index=str, columns={"level_0": "categoria", 0: "palabras"})
df_bolsa_palabras = df_bolsa_palabras.loc[df_bolsa_palabras['palabras'] != '', ['categoria', 'palabras']]
# Hace un split del texto en la columna palabras, y elimina aquellas de longitud menor o igual a 2 caracteres
df_bolsa_palabras['palabras'] = df_bolsa_palabras['palabras'].apply(lambda x: [p for p in x.split() if len(p)>2])
df_bolsa_palabras['num_palabras'] = df_bolsa_palabras['palabras'].apply(lambda x: len(x))
# Organiza la bolsa de palabras para utilizar primero las que mƔs tƩrminos tienen y finalizar con las de menos
# lo anterior con el fin de minimizar errores aprovechando las coincidencias mƔs amplias (mƔs tƩrminos)
df_bolsa_palabras = df_bolsa_palabras.sort_values(by='num_palabras', ascending=False).reset_index(drop=True)
# Presenta el dataframe con la bolsa de palabras resultante
df_bolsa_palabras.iloc[head_tail(5)]
%%time
# Genera un arreglo con el total de palabras disponibles en la matriz TF
palabras_tf = sdf.columns.tolist()
# Variable para almacenar la cantidad de transacciones que se logran clasificar a travƩs de este mƩtodo.
trx_clasificadas = 0
# Hace un loop por los tĆ©rminos de la bolsa de palabras, y asocia la categorĆa respectiva a los registros en el dataframe df_trxpse
for ind, fila in df_bolsa_palabras.iterrows():
if all(palabra in palabras_tf for palabra in fila['palabras']):
filtro_trx = sdf[fila['palabras']].sum(axis=1) == fila['num_palabras']
df_trxpse.loc[(filtro_trx.tolist()) & (df_trxpse['categoria'] == ''), 'categoria'] = fila['categoria']
trx_palabras = sum(filtro_trx)
trx_clasificadas += trx_palabras
if trx_palabras>0:
print(f'Si: {fila["palabras"]} - {trx_palabras}')
else:
pass #print(f'No estƔn todos los tƩrminos: {fila["palabras"]}')
print(f'Total de transacciones clasificadas a travƩs de este mƩtodo {trx_clasificadas}')
# Presenta el grƔfico con los resultados obtenidos hasta el momento
fig, ax = plt.subplots()
sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
Se encuentra que con esta herramienta, cuya complejidad es baja pues solo requiere que un grupo de personas acuerden tĆ©rminos comunes asociados a cada categorĆa, se lograrĆan cubrir casi 7 millones de transacciones, aunque acĆ” se suman algunas que ya tenĆan categorĆa asignada a partir de uno de los mĆ©todos anteriores, por lo cual, luego de aplicar esta metodologĆa, aun quedan aproximadamente 3M de transacciones pendientes.
En la siguiente sección se harÔ una exploración adicional que busca terminar de clasificar las transacciones pendientes.
En esta etapa se harĆ” una exploración de los datos que aun no tienen categorĆa mediante un ejercicio de clusterización, bajo el cual se buscarĆ” identificar mediante el mĆ©todo del codo cuĆ”l es la cantidad ideal de clusters para identificar los datos, y posteriormente se analizarĆ” la información que quede en cada cluster para validar si se identifican nuevas transacciones a agrupar en las diferentes categorĆas.
Este procesamiento se harĆ” sobre los registros a los que no se les ha asignado aun una categorĆa.
%%time
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo, pero no sobre dataframe original (df_trxpse) sino sobre el resumido (df_texto_trxpse)
df_texto = pd.DataFrame(pd.Series(df_trxpse.loc[df_trxpse['categoria'] == '',cols_texto].fillna('').values.tolist()).str.join(' '), columns=['texto'])
# Unifica las 3 fuentes de palabras a excluir en un solo arreglo
excluded_words = stopwords_spanish + no_relevant_words + low_frec_words.tolist() + unique_words.tolist() + two_char_words.tolist()
# Genera la matriz TF-IDF
vectorizer = TfidfVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
X_tf_idf = vectorizer.fit_transform(df_texto['texto'])
print(f"La matriz TF-IDF queda con un total de {X_tf_idf.shape[0]} textos diferentes, y {X_tf_idf.shape[1]} tƩrminos asociados luego de los filtros")
%%time
# Define las cantidades de los clusters a generar (mĆnimo, mĆ”ximo, paso entre cantidades)
min_k = 5
max_k = 50
step_k = 10
# Crea un dataframe donde se almacenarÔn los resultados de las evaluaciones de cada combinación de clusters
res_clusters = pd.DataFrame({'k': np.arange(min_k, max_k, step_k)}, columns=['k'])
res_clusters['wcss'] = 0
# Comienza a crearlos cluster indicados, guardando los resultados asociados.
for ind in res_clusters.index:
val_k = res_clusters.at[ind,'k']
clusterer = KMeans(n_clusters = val_k, random_state = 42)
cluster_labels = clusterer.fit_predict(X_tf_idf)
wcss = clusterer.inertia_
res_clusters.at[ind,'wcss'] = wcss
#Agrega la información del cluster asignado, para el postprocesamiento
df_texto['k'+str(val_k).zfill(2)] = cluster_labels
print(f"Finalizó el proceso para k={val_k}")
res_clusters
# Genera la grÔfica del codo, para identificar la cantidad óptima de clusters
res_clusters.plot(x='k', y='wcss', marker='o');
plt.xticks(res_clusters['k']);
Se encuentra hasta este punto que hay un codo con k=15, por lo cual se procede a tomar esta cantidad de clusters y a hacer un anĆ”lisis de las palabras asociadas a cada uno de ellos, para a partir de esto definir cuĆ”l es la categorĆa respectiva e ir avanzando en la asignación de una clasificación por grupos de palabras.
# Define el K ideal y una cadena de caracteres asociado a esta cantidad, que se pueda usar para seleccionar la información asociada.
k_ideal = 15
k_ideal_str = 'k'+str(k_ideal).zfill(2)
df_texto[k_ideal_str].value_counts()
df_texto.iloc[head_tail(10)]
A partir de la información asociada a cada cluster, procede a mostrar las palabras mÔs frecuentes en el texto de las transacciones de cada cluster.
for k_i in range(k_ideal):
print('Cluster {}'.format(k_i))
# Genera la matriz TF para la totalidad de palabras en el dataset de transacciones
vectorizer = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
X_tf = vectorizer.fit_transform(df_texto.loc[df_texto[k_ideal_str] == k_i, 'texto'])
# Obtiene el arreglo con las palabras y la frecuencia asociada a cada una
words = np.array(vectorizer.get_feature_names())
counts = X_tf.sum(axis=0).A1
# Crea un dataframe con la totalidad de palabras, y la frecuencia de cada una
all_words = pd.DataFrame({'frecuencia': counts}, index=words).sort_values(by='frecuencia', ascending=False)
# Crea ua grƔfica con los principales 25 tƩrminos de cada cluster
all_words.head(25).plot.barh(title='Top 25 Palabras mƔs Frecuentes - '+str(k_ideal)+' clusters - cluster '+str(k_i));
plt.gca().invert_yaxis();
Se encuentra que hay un cluster grande con mĆ”s de 1M de registros y que tiene una gran dispersión de tĆ©rminos diferentes, que no son claramente relacionables frente a una categorĆa.
Por otro lado, se identifica que varios de los clusters tienen como factor comĆŗn las palabras "servicios" y "financieros", lo cual estĆ” atado a pagos de clientes de Bancolombia que se hacen a otros bancos pero para los cuales no es fĆ”cil identificar cuĆ”l es su destino final. Debido a esto se procede a crear una nueva categorĆa denominada "Otros servicios bancarios" donde se agruparĆ”n todas estas transacciones.
AsĆ mismo, se identifica que en general los demĆ”s clusters estĆ”n asociados a tĆ©rminos en los que es dificil identificar cuĆ”l es su categorĆa, por lo cual estos se llevan a la categorĆa "Otros"
# Primero se hace la clasificación de los registros por sector y subsector
df_trxpse.loc[(df_trxpse['categoria'] == '') &
(df_trxpse['sector'] == 'servicios financieros'), 'categoria'] = 'Otros servicios financieros'
# Finalmente, hace una categorización de los registros que aun estĆ©n pendientes por clasificar, bajo la categorĆa "Otros"
df_trxpse.loc[(df_trxpse['categoria'] == ''), 'categoria'] = 'Otros'
# Presenta el grƔfico con los resultados obtenidos hasta el momento, organizƔndolos por frecuencia
fig, ax = plt.subplots()
sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue',
order=df_trxpse['categoria'].value_counts().index);
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
Se encuentra hasta acĆ” que luego de aplicar las diferentes metodologĆas se logra clasificar mĆ”s de la mitad de los registros, teniendo presente que durante el proceso se buscó ser estrictos para evitar terminar clasificando transacciones en la categorĆa incorrecta. Para corroborar esto, en la siguiente sección se harĆ” una descripción de los principales tĆ©rminos asociados a cada categorĆa.
A continuación se hace una descripción de las diferentes categorĆas identificadas, logrando encontrar las palabras mĆ”s frecuentes para cada una mediante un countplot y un WordCloud que presentan las palabras mĆ”s comunes para cada categorĆa.
# Genera un arreglo de "excluded_words", sin tener en cuenta las de baja frecuencia para lograr un anƔlisis mƔs profundo
excluded_words = stopwords_spanish + no_relevant_words + two_char_words.tolist()
# Define el tamaƱo de las grƔficas
#fig, ax = plt.subplots(figsize=(8,3))
# Hace una iteración entre las diferentes categorĆas para generar el top 20 de las palabras mĆ”s frecuentes
resultados = pd.DataFrame(df_trxpse['categoria'].value_counts())
for ind, fila in resultados.iterrows():
vectorizer = {}
if fila['categoria']>0:
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo
df_texto = pd.DataFrame(pd.Series(df_trxpse.loc[df_trxpse['categoria'] == ind, cols_texto].values.tolist()).str.join(' '), columns=['texto'])
# Genera la matriz TF para la totalidad de palabras en el dataset de transacciones, excluyendo solo stopwords
vectorizer[ind] = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
X_tf = vectorizer[ind].fit_transform(df_texto['texto'])
# Obtiene el arreglo con las palabras y la frecuencia asociada a cada una
words = np.array(vectorizer[ind].get_feature_names())
counts = X_tf.sum(axis=0).A1
# Crea un dataframe con la totalidad de palabras, y la frecuencia de cada una
all_words = pd.DataFrame({'frecuencia': counts}, index=words).sort_values(by='frecuencia', ascending=False)
# Crea la grƔfica asociada
all_words.head(20).plot.barh(title='Top 25 Palabras mƔs Frecuentes categoria '+ind);
plt.gca().invert_yaxis();
plt.show()
# Genera un wordcloud con las palabras mƔs frecuentes
wordcloud = WordCloud(width=800,
height=400,
min_font_size=8,
max_font_size=60,
relative_scaling=0.4,
background_color="white").generate_from_frequencies(all_words['frecuencia'].head(200).to_dict())
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()
#df_trxpse[df_trxpse['ref1'].str.contains('panamericanacomco ')].groupby(cols_texto+['categoria']).size().reset_index().sort_values(by=0, ascending=False)
df_texto[df_texto['texto'].str.contains('pregrado')].groupby(['texto']).size().reset_index().sort_values(by=0, ascending=False)
#df_trxpse.loc[df_trxpse['categoria'] == 'Viajes' , cols_texto]
df_trxpse.to_csv('./trx_pse_clasificado.csv',index=False)
!ls
# 2. Create & upload a file text file.
uploaded = drive.CreateFile({'title': 'trx_pse_clasificado.csv'})
uploaded.SetContentFile('trx_pse_clasificado.csv')
uploaded.Upload()
print('Uploaded file with ID {}'.format(uploaded.get('id')))
#1OXlhMm9ulvEj1ONS2QDLHCJ0v5siOqo_
#1LTq2ijSn2RSiNBHeoBZkWfakDPdEo4W8
Dentro de las aplicaciones de este ejercicio, es interesante analizar si es posible generar un modelo de clasificación que pueda ser usado en conjunto con un PFM para facilitar llevar las cuentas con una herramienta , es por esto que nos tomamos la tarea de desarrollarlo luego de clasificar las transacciones. Dentro de los modelos que generalmente han demostrado mejor desempeño en la clasificación de texto han sobresalido la Naive Bayes y la maquina de soporte vectorial lineal, por lo tanto nos propusimos a crearlos
!pip install imbalanced-learn
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.model_selection import GridSearchCV
from imblearn.pipeline import Pipeline as make_pipeline_imb
from imblearn.metrics import classification_report_imbalanced
from imblearn.over_sampling import SMOTE
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from xgboost import XGBClassifier
from sklearn.metrics import classification_report,accuracy_score
# Crea la función para procesar el texto de los dataframes
def organiza_texto_df(df_proceso, columnas='', type_category=False):
# Si no se pasó un vector de columnas, crea un vector con todas las columnas del dataframe
if len(columnas) == 0:
columnas = df_proceso.columns.values
for col in columnas:
# Si se pasó la bandera de que se debe trabajar con dtype = category, hace el procesamiento teniendo en cuenta esto (category -> object -> category)
if type_category:
df_proceso[col] = df_proceso[col].astype('object').fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ').astype('category')
# De lo contrario, trabaja directamente con la columna teniendo en cuenta que es string
else:
df_proceso[col] = df_proceso[col].fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ')
print(f'Columna {col} procesada')
#Devuelve el dataframe prosesado
return(df_proceso)
# Buscando eficiencia, cargamos unicamente las columnas siguientes
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion','categoria']
dtypes = {'ref1':'str', 'ref2':'str', 'ref3':'str', 'sector':'str', 'subsector':'str', 'descripcion':'str','categoria':'str'}
df_trx_pse_clasificado = pd.read_csv('trx_pse_clasificado.csv', usecols=[3,4,5,6,7,8,15], dtype=dtypes, names=cols_texto,header=None)
%%time
# Genera un arreglo con los nombres de los campos de texto que describen la transacción
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion']
df_trx_pse_clasificado = organiza_texto_df(df_trx_pse_clasificado, cols_texto, True)
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion']
df_trx_pse_clasificado['Texto']=pd.Series(df_trx_pse_clasificado[cols_texto].values.tolist()).str.join(' ')
df_trx_pse_clasificado.drop(['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion'],axis=1,inplace=True)
# Valida la calidad de los datos
%%time
import re
def clean(texto):
clean_texto=re.sub("[^a-zA-Z-0-9]", " ", str(texto))
return clean_texto
df_trx_pse_clasificado['Texto']=df_trx_pse_clasificado['Texto'].apply(clean)
# Valida la calidad de los datos
df_trx_pse_clasificado=df_trx_pse_clasificado[df_trx_pse_clasificado['categoria'].isna()==False]
df_trx_pse_clasificado=df_trx_pse_clasificado[df_trx_pse_clasificado['categoria']!='categoria']
# Carga las palabras que se identificaron previamente como no deseadas
df_excluded_words = pd.read_csv('excluded_words.csv',sep=';')
df_excluded_words=df_excluded_words[df_excluded_words['0'].isna()==False]
excluded_words=df_excluded_words['0'].values.tolist()
X = df_trx_pse_clasificado['Texto']
y = df_trx_pse_clasificado['categoria']
y.value_counts()
Ya que se cuenta con un set de datos relativamente grande, y dado el caso de que no contamos con recursos para procesar todos los datos, se extrae una muestra del dataset para entrenar un modelo de clasificación.
# Genera un sample de todo el dataset proporcionalmente a la cantidad de cata categotia
from sklearn.model_selection import StratifiedShuffleSplit
sss = StratifiedShuffleSplit(n_splits=250000000,test_size=0.5,random_state=42)
sample_train = []
sample_test = []
for train_sample,test_sample in sss.split(X,y):
sample_train.append(train_sample)
sample_test.append(test_sample)
break
# Se filtra con los nuevos indices
X=df_trx_pse_clasificado.iloc[sample_train[0]]['Texto']
y=df_trx_pse_clasificado.iloc[sample_train[0]]['categoria']
# Se verifica la distribución de categorias para la muestra extraida
y.value_counts()
y.shape
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=42)
Bayes_text_clf = make_pipeline_imb([('tfidf', TfidfVectorizer(encoding='latin-1',stop_words=excluded_words)),
('SMOTE',SMOTE(random_state=42)),
('MultinomialNB', MultinomialNB())])
Bayes_param = {'MultinomialNB__alpha': [1]}
clf_GC_Bayes = GridSearchCV(Bayes_text_clf,Bayes_param,cv=2,n_jobs=5)
%%time
clf_GC_Bayes.fit(X_train,y_train)
%%time
y_pred = clf_GC_Bayes.predict(X_test)
print(classification_report(y_test,y_pred))
Ya que solo entrenamos el dataset con una pequeña porción del set, comprobamos directamente contra todo el dataframe original
y_pred = clf_GC_Bayes.predict(df_trx_pse_clasificado['Texto'])
print(classification_report(df_trx_pse_clasificado['categoria'],y_pred))
SVM_text_clf = make_pipeline_imb([('tfidf', TfidfVectorizer(encoding='latin-1',stop_words=excluded_words)),
('SMOTE',SMOTE(random_state=42)),
('SVM_lineal', SVC(kernel='linear'))])
SVM_param = {'SVM_lineal__C': [1]}
clf_GC_SMV = GridSearchCV(SVM_text_clf,SVM_param,n_jobs=6)
%%time
clf_GC_SMV.fit(X_train,y_train)
%%time
y_pred = clf_GC_SMV.predict(X_test)
print(classification_report(y_test,y_pred))
Por cuestiones de tiempo, en este Jupyter, no se ejecutó el código, sino en otro equipo Local. Se adjunta imagen en donde se muestra el resultado del entrenamiento de la mÔquina de soporte vectorial en el entrenamiento con 265000 filas probandolo con el split que se utilizo cuando se tenian 8500000 de registros clasificados.
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
%matplotlib inline
plt.figure(figsize=(13,9))
img=mpimg.imread('265000.PNG')
imgplot = plt.imshow(img)
plt.axis('off');
%%time
y_pred = clf_GC_SMV.predict(df_trx_pse_clasificado['Texto'])
print(classification_report(df_trx_pse_clasificado['categoria'],y_pred))
Se adjunta imagen en donde se muestra el resultado del entrenamiento de la mƔquina de soporte vectorial en el entrenamiento con 265000 filas probandolo directamente con todo el dataset.
plt.figure(figsize=(13,9))
img=mpimg.imread('85000000.PNG')
imgplot = plt.imshow(img)
plt.axis('off');
En este punto podemos concluir que el procesamiento de los datos que establecimos permite generar un modelo de clasificación con muy buen desempeño !
Es de resaltar el buen desempeƱo y preprocesamiento de los datos ya que el entrenamiento se realizó con 265890 registros, y se comprobó directamente contra todos los 8.500.000 de datos que se tenĆan
scores = []
classifiers=['RandomForestClassifier','Logistic Regression','XGBOOST']
models=[RandomForestClassifier(n_estimators=100),
LogisticRegression(),
XGBClassifier()]
for model in models:
text_clf = make_pipeline_imb([('tfidf', TfidfVectorizer(encoding='latin-1',stop_words=excluded_words)),
('SMOTE',SMOTE(random_state=42)),
('model', model)])
print(model)
print(' ')
text_clf.fit(X_train,y_train)
y_pred=text_clf.predict(X_test)
scores.append(accuracy_score(y_pred,y_test))
print(classification_report(y_test,y_pred))
print(' ')
#y_pred=text_clf.predict(df_trx_pse_clasificado['Texto'])
#print(classification_report(df_trx_pse_clasificado['categoria'],y_pred))
#print(' ')
models_dataframe=pd.DataFrame(scores,index=classifiers)
models_dataframe.columns=['Accuracy']
models_dataframe.sort_values('Accuracy',ascending=False)
En esta sección se buscarĆ” identificar cuĆ”les son las principales caracterĆsticas de los clientes, que determinan su probabilidad de realizar una transacción, para lo cual se procederĆ” a:
En esta sección se cargarĆ” la información de los clientes, se harĆ”n unos ajustes iniciales como por ejemplo cambiar las categorĆas por la descripción asociada, y lugeo hacer una expliración estadĆstica y visual de los datos.
# Carga la información de los pagadores
df_pagadores = pd.read_csv('./dt_info_pagadores_muestra.csv',
header=None,
sep=",",
names=['id_cliente', 'seg_str', 'ocupacion', 'tipo_vivienda', 'nivel_academico',
'estado_civil', 'genero', 'edad', 'ingreso_rango'],
dtype={'id_cliente': 'object', 'seg_str': 'category',
'ocupacion': 'object', 'tipo_vivienda': 'object',
'nivel_academico': 'object', 'estado_civil': 'object',
'genero': 'object', 'edad': 'object', 'ingreso_rango': 'category'})
# Explora el dataframe resultante
df_pagadores.iloc[head_tail(5)]
# Carga la asignación de variables que se tiene para cada campo, con el fin de hacer el mapeo
map_ocupaciones = {'E': 'SOCIO O EMPLEADO - SOCIO',
'I': 'DESEMPLEADO CON INGRESOS',
'O': 'OTRA',
'P': 'INDEPENDIENTE',
'S': 'DESEMPLEADO SIN INGRESOS',
'1': 'EMPLEADO',
'2': 'ESTUDIANTE',
'3': 'INDEPENDIENTE',
'4': 'HOGAR',
'5': 'JUBILADO',
'6': 'AGRICULTOR',
'7': 'GANADERO',
'8': 'COMERCIANTE',
'9': 'RENTISTA DE CAPITAL'}
map_tipo_vivienda = {'A': 'ALQUILADA',
'R': 'ALQUILADA',
'F': 'FAMILIAR',
'I': 'NO INFORMA',
'P': 'PROPIA',
'O': 'PROPIA'}
map_nivel_academico = {'H': 'BACHILLERATO',
'B': 'BACHILLERATO',
'U': 'UNIVERSITARIO',
'E': 'ESPECIALIZACION',
'N': 'NINGUNO',
'P': 'PRIMARIA',
'S': 'POSTGRADO',
'T': 'TECNICO',
'I': 'NO INFORMA'}
map_estado_civil = {'S': 'SOLTERO',
'M': 'CASADO',
'F': 'DESCONOCIDO',
'I': 'NO INFORMA',
'D': 'DIVORCIADO',
'W': 'VIUDO',
'O': 'OTRO'}
map_genero = {'M': 'Masculino', 'F': 'Femenino'}
#Hace el mapping de cada campo categórico, y lo convierte a tipo category
df_pagadores['ocupacion'] = df_pagadores['ocupacion'].map(map_ocupaciones).astype('category')
df_pagadores['tipo_vivienda'] = df_pagadores['tipo_vivienda'].map(map_tipo_vivienda).astype('category')
df_pagadores['nivel_academico'] = df_pagadores['nivel_academico'].map(map_nivel_academico).astype('category')
df_pagadores['estado_civil'] = df_pagadores['estado_civil'].map(map_estado_civil).astype('category')
df_pagadores['genero'] = df_pagadores['genero'].map(map_genero).astype('category')
# Explora el dataframe resultante
df_pagadores.iloc[head_tail(5)]
# Hace un describe de las diferentes columnas del dataframe
df_desc_numeric = df_pagadores.describe().T.reset_index(level=0).rename(columns={"index": "columna"})
df_desc_numeric
Se encuentra que:
En la siguiente sección se hace un anÔlisis exploratorio de la información de los clientes
# Genera un dataframe a partir del cual se harÔ una la exploración visual de las variables dependiendo de si son numéricas o categóricas
df_dtype = pd.DataFrame(df_pagadores.dtypes).reset_index(level=0)
df_dtype.rename(columns={"index": "columna", 0: "tipo"}, inplace=True) # Renombra las columnas para facilitar su procesamiento
# Crea el espacio para las grƔficas
fig, ax = plt.subplots()
# Procede a crear las grƔficas
filas_numericas = ['id_cliente'] # Variables a excluir de la revisión grÔfica
for index, fila in df_dtype.iterrows():
if fila["columna"] not in filas_numericas:
sns.countplot(y=fila["columna"],data=df_pagadores, color= 'darkblue',
order=df_pagadores[fila["columna"]].value_counts().index);
plt.title("Frecuencia atributo - "+fila["columna"]);
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
plt.show();
A partir de lo que se identifica en cada grƔfica, se procederƔ a hacer los siguientes ajustes iniciales:
MÔs adelante, se procederÔ a hacer una revisión de los valores nulos de cada variable con su respectiva eliminación/imputación.
Finalmente, se harĆ” el tratamiento de los valores atĆpicos de la columna edad, para luego redefinir esta categorĆa y llevarla de nĆŗmeros a rangos.
# Ajusta los valores de estado_civil
df_pagadores.loc[df_pagadores['estado_civil'] == 'DESCONOCIDO', 'estado_civil'] = 'NO INFORMA'
df_pagadores['estado_civil'] = df_pagadores['estado_civil'].astype('object').astype('category')
df_pagadores['estado_civil'].value_counts()
# Ajusta el formato de la columna del rango de ingresos
df_pagadores['ingreso_rango'] = df_pagadores['ingreso_rango'].str.slice(3)
#Revisa los campos no numƩricos de la variable edad
import re
def valida_no_num(texto):
no_num=re.sub("[0-9]", "", str(texto))
return no_num
valores_no_num=df_pagadores['edad'].apply(valida_no_num)
valores_no_num.value_counts()
Se encuentra que hay 2 tipos de texto diferentes en el campo edad, por lo cual se procede a eliminar esos valores y a reemplazarlos por Nan.
# Reemplaza los valores extraƱos, convierte la columna en numĆ©rica y reemplaza los valores atĆpicos por la mediana
df_pagadores['edad'] = df_pagadores['edad'].replace('\\N', None)
df_pagadores['edad'] = df_pagadores['edad'].replace('-', None)
df_pagadores['edad'] = df_pagadores['edad'].astype('int16')
df_pagadores.iloc[head_tail(5)]
Antes de proceder con el anÔlisis e imputación de los valores de edad, se procede con un anÔlisis general de valores nulos, que busca identificar si hay columnas o filas con una gran cantidad de nulos, y a partir de ello se defina si se eliminan o solo se imputan.
#Carga la cantidad de filas y de columnas del dataframe
df_pagadores_filas, df_pagadores_columnas = df_pagadores.shape[0], df_pagadores.shape[1]
# Valida la cantidad de nulos en cada una de las columnas, y % frente a la cantidad total de registros
umbral_nan = 20
df_nan = pd.DataFrame(df_pagadores.isnull().sum()).reset_index(level=0)
df_nan.rename(columns={"index": "columna", 0: "num_nulos"}, inplace=True)
df_nan['pct_nulos'] = (df_nan['num_nulos']/df_pagadores_filas)*100
df_nan.sort_values(by=['pct_nulos'], ascending=False).iloc[0:11,:].plot.barh(x='columna',
y='pct_nulos',
color='darkgray')
# Genera grƔfica con el porcentaje de nulos por cada campo
plt.axvline(umbral_nan, color='red', linestyle='--');
plt.title('Top 25 Palabras mƔs Frecuentes');
# Ahora valida la cantidad de nulos en cada una de las filas, y % frente a la cantidad total de registros
umbral_nan_r = 50
df_nan_r = pd.DataFrame(df_pagadores.isnull().sum(axis=1)).reset_index(level=0)
df_nan_r.rename(columns={"index": "indice", 0: "num_nulos"}, inplace=True)
df_nan_r['pct_nulos'] = (df_nan_r['num_nulos']/df_pagadores_columnas)*100
df_nan_r.sort_values(by=['pct_nulos'], ascending=False).iloc[0:31,:].plot.barh(x='indice',
y='pct_nulos',
color='darkgray',
figsize=(10, 10))
plt.axvline(umbral_nan_r, color='red', linestyle='--');
plt.title('Top 30 registros con mayor cantidad de nulos');
df_nan_r['pct_nulos'].value_counts()
Se encuentra que hay varios campos nulos, para estos se procede de la siguiente forma:
#Eliminar la columna tipo_vivienda
df_pagadores = df_pagadores.drop('tipo_vivienda', axis=1)
#Elimina las filas que tienen una cantidad de nulos superior al umbral
df_pagadores.dropna(axis="index", thresh=(df_pagadores_columnas*(100-umbral_nan_r)/100), inplace=True)
# Columnas a reemplazar por la moda
moda = df_pagadores['ocupacion'].mode().iloc[0]
df_pagadores['ocupacion'].fillna(moda, inplace=True)
moda = df_pagadores['genero'].mode().iloc[0]
df_pagadores['genero'].fillna(moda, inplace=True)
# Columnas a reemplazar por "NO INFORMA"
df_pagadores['nivel_academico'].fillna('NO INFORMA', inplace=True)
df_pagadores['estado_civil'].fillna('NO INFORMA', inplace=True)
Finalmente, se procede a ajustar los valores de la columna edad, que es la única numérica dentro de la información de los pagadores (clientes). Para esto se harÔ primero un "describe" del campo y luego una exploración mediante boxplots.
# hace un ddescribe de la columna edad
df_pagadores['edad'].describe()
# Grafica el boxplot de los valores del campo edad
fig, ax = plt.subplots(figsize=(8,3))
df_pagadores['edad'].plot.box(vert=False);
plt.title("Edades de los clientes");
ax.set(xlabel='AƱos', ylabel='');
df_pagadores['edad'].sort_values().iloc[head_tail(5)]
Se encuentra que hay valores atĆpicos asociados a edades negativas o iguales a 0, por lo cual se procede a hacer un reemplazo de todas las edades menores a 5 aƱos por la mediana.
Asà mismo, se encuentra que hay múltiples registros con edad de 118 años, por lo cual se procede a cambiar también las edades mayores a 95 años también por la mediana.
# Hace el ajuste para los atĆpicos
df_pagadores.loc[df_pagadores['edad'] < 5, 'edad'] = df_pagadores['edad'].median()
df_pagadores.loc[df_pagadores['edad'] > 95, 'edad'] = df_pagadores['edad'].median()
# Cambia los valores nulos por la mediana
df_pagadores['edad'] = df_pagadores['edad'].fillna(df_pagadores['edad'].median())
# Grafica la cantidad de palabras de los campos descriptores de la transacción contactenados
fig, ax = plt.subplots(figsize=(8,3))
df_pagadores['edad'].plot.box(vert=False);
plt.title("Edades de los clientes");
ax.set(xlabel='AƱos', ylabel='');
Se encuentra que las edades quedan ya en rangos adecuados para continuar con el anĆ”lisis. Finalmente, se procede cambiar esta variable a categorĆas, para facilitar el entendimiento de los datos que se busca mediante este ejercicio.
Para esto, se procede a crear una función que genera un texto para cada edad, indicando en qué rango estÔ.
# FUnción para discrtizar las edades
def discretiza_edades(edad):
min_edad = 20
max_edad = 90
salto = 10
if edad<min_edad:
return('<'+str(int(min_edad)))
elif edad>=max_edad:
return('>='+str(int(max_edad)))
else:
div = (edad//salto)*salto
return(str(int(div))+'-'+str(int(div+salto-1)))
df_pagadores['edad'] = df_pagadores['edad'].apply(discretiza_edades)
df_pagadores.iloc[head_tail(5)]
Para cerrar este ejercicio, se hace una Ćŗltima exploración de la frecuencia de cada categorĆa en cada una de las variables.
# Genera un dataframe a partir del cual se harÔ una la exploración visual de las variables dependiendo de si son numéricas o categóricas
df_dtype = pd.DataFrame(df_pagadores.dtypes).reset_index(level=0)
df_dtype.rename(columns={"index": "columna", 0: "tipo"}, inplace=True) # Renombra las columnas para facilitar su procesamiento
fig, ax = plt.subplots()
filas_numericas = ['id_cliente']
i=0 # Variable para almacenar la posición de la grÔfica asociada
for index, fila in df_dtype.iterrows():
if fila["columna"] not in filas_numericas:
sns.countplot(y=fila["columna"],data=df_pagadores, color= 'darkblue',
order=df_pagadores[fila["columna"]].value_counts().index);
plt.title("Frecuencia atributo - "+fila["columna"]);
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
plt.show();
Como en este ejercicio se busca identificar cuĆ”l es el perfil especĆfico de los clientes que hacen cada tipo de transacción, el Ćŗltimo proceso que se harĆ” con los datos es generar dummy variables para cada categorĆa, de tal forma que posteriormente se puedan validar cuĆ”les de las columnas dummy tienen mayor correlación con la variable buscada.
df_pagadores_dummies = pd.get_dummies(df_pagadores[['seg_str', 'ocupacion', 'nivel_academico', 'estado_civil',
'genero', 'edad', 'ingreso_rango']],
prefix=['segm', 'ocup', 'estu', 'eciv', 'gen', 'edad', 'ingr'])
df_pagadores_dummies['id_cliente'] = df_pagadores['id_cliente']
df_pagadores_dummies.set_index('id_cliente', inplace=True)
df_pagadores_dummies.iloc[head_tail(5)]
Finalmente, y como lo que se busca es revisar la relación entre los clientes y el tipo de transacciones que realiza, se crea un dataframe que agrupa para cada cliente la cantidad de transacciones que hace. Para esto se utiliza la función pivot_table.
# Crea la pivot_table con la información requerida
df_trxpse_x_cliente = df_trxpse.pivot_table(values=['valor_trx'],
index=['id_cliente'],
columns=['categoria'],
margins=False,
aggfunc=np.sum)
# Organiza los datos (eliminar levels)
df_trxpse_x_cliente.columns = df_trxpse_x_cliente.columns.droplevel()
df_trxpse_x_cliente = df_trxpse_x_cliente.rename_axis(None).rename_axis(None, axis=1)
# Cambia los NaN por 0
df_trxpse_x_cliente.fillna(0, inplace=True)
# Explora el DF resultante
df_trxpse_x_cliente.head()
# Crea una nueva matriz booleana que indica si tuvo o no transacciones
df_trxpse_x_cliente_bool = df_trxpse_x_cliente>1000
# Convierte la matriz en 1 y 0
df_trxpse_x_cliente_bool = df_trxpse_x_cliente_bool.astype(int)
df_trxpse_x_cliente_bool.head()
Ya teniendo preparadas tanto la matriz de los pagadores (clientes) como la matriz de las transacciones por cliente, por valor y booleana, se procede a hacer el merge.
# Hace el merge entre los 2 sets de datos, mediante el id_cliente (una columna en una, y el Ćndice en la otra)
df_clientes_trxpse = pd.merge(df_pagadores_dummies,
df_trxpse_x_cliente_bool,
how='left',
left_index=True,
right_index=True)
# Explora los resultados
df_clientes_trxpse.iloc[head_tail(5)]
df_clientes_trxpse.shape
Para el modelamiento de las principales caracterĆsticas de cliente que se asocian a cada tipo de transacción, se crearĆ” una matriz de correlaciones a partir del dataframe que agrupa caracterĆsticas de cliente y si ha realizado o no transacciones de cada tipo, de tal forma que luego se compare cuĆ”les son los atributos que mayor correlación tienen.
# Crea el listado de columnas tanto de caracterĆsticas de clientes como de categorĆas de trasancciones
columnas_cliente = df_pagadores_dummies.columns.tolist()
columnas_trxpse = df_trxpse_x_cliente.columns.tolist()
# Genera la matriz de correlaciones completa
mat_corr = df_clientes_trxpse.corr()
#Luego hace una iteración por cada una de las categorĆas de las transacciones
for categoria in columnas_trxpse:
# Dibuja el heat map asociado, dejando fijos los valores mĆnimos y mĆ”ximos para poder comparar
fig, axes = plt.subplots(figsize=(10, 6))
sns.heatmap(mat_corr.loc[columnas_cliente, [categoria]].sort_values(by=categoria, ascending=False).head(10).loc[mat_corr[categoria]>0.02],
linewidths=.2, square=True, cbar=False, annot=True, vmin=0.0, vmax=0.3);
plt.title(f'Principales caracterĆsticas para "{categoria}"');
descargar app : http:/urlappZenAI
plt.figure(figsize=(15,10))
img=mpimg.imread('mock_up.jpeg')
imgplot = plt.imshow(img)
plt.axis('off');
Partiendo del concepto de un PFM , con la información de las transacciones suministrada podria ayudar a los usuarios a clasificar sus gastos realizados por PSE.
Por otro lado, las PFM podrian indentificar el comportamiento de ciertos gastos recurrentes como lo son los pagos de servicios publicos, en donde la PFM generaria recordatorios para realizar los pagos.